Skip to content

fix(app-shell): a failed package-list refresh is not a deletion (objectui#7821) - #7879

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-7821-onmanagechanged-false-deletion-eviction
Sep 6, 2026
Merged

fix(app-shell): a failed package-list refresh is not a deletion (objectui#7821)#7879
os-sam merged 1 commit into
mainfrom
claude/issue-7821-onmanagechanged-false-deletion-eviction

Conversation

@claude

@claude claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #7821

The defect, re-verified on today's main (384715bb1)

onManageChanged — the callback the Studio's PackageDetailSheet fires after every
package lifecycle action (disable / duplicate / delete / publish / manifest edit) —
refreshed the package list into a local list, initialised to [], and swallowed
the rejection under a comment reading /* keep the stale list */.

That comment is true of the pkgs state, which is simply not written. It is false
of the local, which stayed []. So after a failed GET /api/v1/packages:

  • !list.some((p) => p.id === managedId) three lines down was unconditionally true,
  • the code took the branch labelled // Deleted,
  • and when the managed package was the one under the editor it navigated away with
    list[0] undefined — i.e. to /home.

One transient 503, network blip or auth expiry threw the author out of the Studio
editor
, with no toast and no confirmation, while the package was still there. Every
link of that chain was re-read on today's main before the fix (PR #7822 landed in this
same file at 00:30:12Z); all four still held.

This is the sibling of #7368 escalated: not a swallowed failure, but a swallowed failure
that then decides the opposite of the truth.

The fix

The local now starts as null — "the refresh told us nothing" — and only a list that
actually came back, without the managed package in it, is read as a deletion. A
failure draws no inference at all: no navigation.

It is reported instead, through the posture this surface already carries (#7368,
reused rather than duplicated): formatMetadataError on the shared studio-package-list
sonner id, and recorded into the existing pkgsErr slot so the switcher reads
pkgListState === 'failed' rather than presenting a now-stale list as current. A
successful refresh clears that slot, since the names on screen are then genuinely fresh.

Still a .catch and still no retry — one 503 must not take the Studio down (#7368's
ruling), and no retry policy (count, backoff, what to show after giving up) has been
ruled on. No new error state machine.

Evidence — behavioural, five pins

packages/app-shell/src/views/studio-design/StudioDesignSurface.packageDeletionInference.test.tsx
drives the real switcher (trigger, "Package info & settings", lifecycle onChanged) and
varies only what the refresh does:

  1. FAILED refresh: the author is NOT evicted — no navigation at all; still on
    /studio/app.b2r4/interfaces, /home never mounts.
  2. FAILED refresh: still REPORTED — the error's own message on the
    studio-package-list sonner id, the trigger reads failed, and the top bar is still
    a working trigger (never a throw).
  3. REAL deletion, nothing left — still navigates to /home, unchanged.
  4. REAL deletion, a sibling survives — still navigates to that sibling, unchanged.
  5. Successful refresh with the package still present — no navigation, state back to
    loaded.

Pins 3 and 4 are the ones that stop this fix degrading into "never navigate", which
would strand the author on a package that no longer exists.

Ablation (implementation committed first, restore trap armed only after): reverting
the distinction — let list: PkgEntry[] = [] plus the bare !list.some(...) guard,
keeping the reporting — with the mutation proven on disk before running (blob hash
781e0c89 to 65393017; anchor counts flipped 1/1/0/0 to 0/0/1/1). Result:

 × FAILED refresh: the author is NOT evicted — no navigation at all
   AssertionError: expected '/home' to be '/studio/app.b2r4/interfaces'

Pin 2 fell with it — the eviction unmounts the switcher, so the trigger cannot be found
— which is the eviction's own signature. Pins 3, 4 and 5 stayed green, so pin 1 is
not merely restating them. No rebuild was needed: the pins import
./StudioDesignSurface relatively, inside the package, so nothing resolves through
dist/ or package exports. Restoration proven by state: git diff HEAD empty, disk
hash equal to the HEAD blob, git status clean, anchors back to 1/1/0.

What was run (on a95ee67a9, the head of this branch)

command verdict
pnpm exec vitest run packages/app-shell/src/views/studio-design/ apps/console/src/components/StudioRoute.test.tsx Test Files 50 passed (50) · Tests 276 passed (276)
pnpm --filter @object-ui/app-shell run type-check (tsc --noEmit && tsc -p tsconfig.test.json) exit 0 — --listFiles confirms both changed files are in the program
pnpm --filter @object-ui/app-shell run lint 1078 files, 0 errors; neither changed file contributes a warning
node scripts/check-changeset-presence.mjs ✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
pnpm changeset:check · check:vi-mock-specifiers · check:vi-mock-inherit · check:control-bytes · check:i18n-keys · check:lint-coverage · check:type-check-coverage · check:unreferenced-sources · check:i18n-drift all exit 0

Declared narrowing: repo-wide pnpm lint / pnpm test were left to CI. turbo run lint
runs each package's own eslint .; the only package this diff touches is
@object-ui/app-shell, whose full population (1078 files, counted from eslint's own
--format json output, not from a hand-picked list) was linted here. eslint.config.js
declares no project / projectService, i.e. no type-aware linting, so this diff cannot
move a verdict in a file it does not touch.

Scope

packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx (the
onManageChanged site only), one new test file beside it, one changeset. No new export,
no signature change, no gate change.

Related, deliberately left alone: #7373 owns whether the recovery destination should
be a hard-coded /home rather than the declared landing page. This PR only stops the
redirect firing on a false premise; the destination it fires to on a real deletion is
byte-for-byte what it was.


Generated by Claude Code

…ctui#7821)

`onManageChanged` refreshed the package list into a local `list` initialised to
`[]` and swallowed the rejection under a comment reading "keep the stale list".
That is true of the `pkgs` state — which is simply not written — and false of
the local, which stayed `[]`. So after a failed `GET /api/v1/packages` the
`!list.some((p) => p.id === managedId)` check three lines down was
unconditionally true, the code took the branch labelled `// Deleted`, and when
the managed package was the one under the editor it navigated away with
`list[0]` undefined — to `/home`. One transient 503 evicted the author from the
Studio editor, silently, while the package was still there.

The local now starts as `null` ("the refresh told us nothing"), and only a list
that actually came back, without the managed package in it, is read as a
deletion. A failure draws no inference: no navigation. It is reported instead
through the posture this surface already carries — `formatMetadataError` on the
shared `studio-package-list` sonner id, and recorded so the switcher reads
`failed` rather than presenting a stale list as current.

Still a `.catch` and still no retry: one 503 must not take the Studio down, and
no retry policy has been ruled on.

Behavioural pins in StudioDesignSurface.packageDeletionInference.test.tsx cover
all three legs: no navigation on failure, the failure still reported, and a real
deletion navigating exactly as before (to a surviving sibling, or `/home`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3187.3 KB 3191.4 KB
Main entry chunk (gzip) 143.5 KB 350 KB
Entry file index-IyiIL8QW.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 510.60KB 116.20KB
core (index.js) 6.96KB 2.79KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 182.08KB 50.62KB
fields (index.js) 242.44KB 61.25KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.98KB 10.98KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 47.87KB 13.31KB
plugin-charts (index.js) 70.92KB 19.75KB
plugin-chatbot (index.js) 196.19KB 46.37KB
plugin-dashboard (index.js) 132.88KB 34.69KB
plugin-designer (index.js) 212.86KB 43.19KB
plugin-detail (index.js) 250.55KB 64.06KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.87KB 32.66KB
plugin-gantt (index.js) 167.26KB 41.00KB
plugin-grid (index.js) 209.29KB 56.78KB
plugin-kanban (index.js) 52.71KB 14.55KB
plugin-list (index.js) 113.76KB 27.75KB
plugin-map (index.js) 20.44KB 6.78KB
plugin-markdown (index.js) 13.93KB 4.81KB
plugin-report (index.js) 43.59KB 11.97KB
plugin-timeline (index.js) 30.84KB 8.85KB
plugin-tree (index.js) 9.20KB 3.19KB
plugin-view (index.js) 85.24KB 20.94KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sam
os-sam marked this pull request as ready for review September 6, 2026 01:34
@os-sam
os-sam added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit 655afab Sep 6, 2026
33 checks passed
@os-sam
os-sam deleted the claude/issue-7821-onmanagechanged-false-deletion-eviction branch September 6, 2026 01:47
os-sam pushed a commit that referenced this pull request Sep 6, 2026
…tui#7881)

`fetchFullPackage` — the `PackageSwitcher` helper behind "Package info &
settings" — fetched `/api/v1/packages` and went straight to `res.json()`,
never reading `res.ok`. The platform answers a failed read in the ADR-0112
envelope, `{ success: false, error: { code, message } }`, and that envelope
parses cleanly through the reader below it: `root` becomes the error object,
which is neither an array nor carries `packages`, so the list fell to `[]` and
`.find()` to `null`. Nothing threw, so `openManage`'s `catch` — the one that
toasts `formatMetadataError` — never ran, and the two lines after it still
fired: `setManage(null)` then `setManageOpen(true)`.

`PackageDetailSheet` renders `null` for a null package, so during an outage the
author clicked the menu item and got silence: no sheet, no toast, no
explanation — and `manageOpen` stuck true with no rendered sheet to close it.
Third variant of the objectui#7368 family after objectui#7821 (PR #7879): not a
lost toast and not an inverted decision, but a failure laundered into a
successful-looking empty result. An empty list is a completely legitimate
success answer, which is exactly why it must never be the value a failure
produces.

Measured before deciding what to read. `GET /api/v1/packages` is served by the
direct-mount registrar, which mounts first in the production stack and is
pinned at zero hand-written bodies, so every failure leaves through the shared
`sendError` / `sendThrownError`: 401 UNAUTHENTICATED, 403 FORBIDDEN, 503
SERVICE_UNAVAILABLE and 500 INTERNAL_ERROR, all one shape. The envelope's own
`success` is therefore not a second bit here — `sendOk` writes `true` on every
2xx and the error writers `false` on every non-2xx, which is `!res.ok`
restated. So `res.ok` is the decision and the envelope is read for the words;
in the 5xx band the platform withholds the producer's prose for the generic
`Internal server error`, leaving `error.code` as the only discriminating word,
so the code travels with the message. A non-JSON error body — the one other
reachable shape, a proxy's HTML 502/504 — names the status instead of the JSON
syntax error the author used to be shown.

Reported through this surface's existing posture, not a second one:
`formatMetadataError` on the shared `studio-package-list` sonner id, so one
outage across this surface's four callers of that endpoint is one toast rather
than four. Deliberately not also recorded in `pkgsErr`: that slot is the
switcher list's own state, written exactly where `pkgs` is, and this callback
never writes `pkgs`.

And the sheet no longer opens on a `null` package at all — this card's
user-visible deliverable. A successful list that does not contain the package
(deleted or uninstalled elsewhere) now says so.

Nine behavioural pins in StudioDesignSurface.packageLookupFailure.test.tsx.
Three of them are negative controls that stay green with the fix reverted, so
the other six are provably not restating an existing assertion — and a "fix"
that merely stopped opening the sheet cannot pass the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(app-shell): onManageChanged reads a failed package-list refresh as "the package was deleted" and evicts the author to /home

2 participants